说在一切之前
各种博客死锁问题的分析和demo还蛮多的,但是真正涉及实际业务的并发情况下的死锁场景后,发现情况还蛮丰富的啊。。。
此外,这篇博客不会讲述具体理论,阅读需要一定的前置知识,比如事务隔离级别、相关锁的概念、死锁条件及加锁原因和场景等等。
背景图与正文无关,只是偶然看见,烬和男枪都被换弹阻塞,结局会怎么样呢?
问题出现
场景
公司每天都会有少量线上死锁问题告警(com.mysql.jdbc.exceptions.jdbc4.MySQLTransactionrollbackException)。大致情况是凌晨x点有上游的定时任务调了批量修改券批次的服务接口。相关表有多个,包含1对1、1对n等关系,博客中会对问题进行简化。
前置处理
环境信息获取
到公司数据库管理平台查看相应问题出现频率以及死锁日志。我截取了近几天的死锁日志并进行归类,分出了最常见、最不常见、较一般(简单)情况三类。
此外,确定线上的数据库版本为5.X(是的,就是大家都在用的那个X版本),事务隔离级别为RR。
查看问题影响
查看日志显示的被rollback事务,发现数据库里相关事务最终依旧成功commit执行,猜测是上游收到该请求失败result后进行了再次请求。
因此目前看来对实际业务无影响,只是会每天告警。
问题分析
事务中涉及到多个表,但此处对于其他表的修改不再展示,只展示导致问题出现的两个表的修改,两个表分别为e 表、a(名称已简化),后续i 代表 insert,d代表delete
事务执行流程简示
对流程简图进行展示:
(配图错了,最后一个是向author表插数据)
表结构分析
这里对相应的两个表简化并改名,具体代表什么见上面的加粗示意。
E表
1 | CREATE TABLE `E` ( |
值得注意的是表中有一个为聚合索引的唯一索引(cid, key)以及一个非唯一索引(cid);
A表
1 | CREATE TABLE `A` ( |
a表则需要注意的是只有一个非唯一键索引(cid);
情况汇总
具体日志信息不便放出,这里对相关事务的锁持有与锁等待等等情况进行汇总,在后续小节的分情况分析时会给出简化版本等待锁的SQL语句。
情况分类 | 事务1持有锁 | 事务1等待锁 | 事务2持有锁 | 事务2等待锁 | 回滚事务 |
---|---|---|---|---|---|
一般(较简单) | - | index IX_CID of table A ; lock_mode X locks gap before rec insert intention |
index IX_CID of table A ; lock_mode X |
index UX_CID_KEY of table E ; lock_mode X |
2 |
最常见(次数最多) | - | index UX_CID_KEY of table E ; lock_mode X |
index UX_CID_KEY of table E ; lock mode S |
index IX_CID of table A ; lock_mode X locks gap before rec insert intention |
1 |
最不常见(较极端) | - | index IX_CID of table E ; lock_mode X locks gap before rec insert intention |
index IX_CID of table E ; lock_mode X |
index UX_CID_KEY of table E ; lock_mode X locks rec but not gap |
2 |
分情况分析
高并发场景下,难以完全复现问题,只能通过部分复现以及理论进行推推演;如果有误,请于评论指出!
此外,在具体SQ中,只截取cid后3位;
一般情况(较简单)
最一般情况下,其时SQL分别为:
-
事务1:insert into
A
(cid, Author) values (103, ‘null’ ); -
事务2:delete from
E
WHERE ( cid = 113 );
第4步再说明:
对于唯一键的插入操作,会找到其后的记录,如果其上有gap锁(此时没有),就会加insert intention lock进行等待;
PS:说明这里insert操作是完整的。
最常见情况
最常见情况下,主要表现为出现S锁,对于其出现,其时SQL分别为:
事务1:delete from E
WHERE ( cid = 205 );
事务2:insert into A
(cid, Author ) values (203, ‘null’ );
几个可能的问题:
-
事务2第四步,为什么是持有S锁?
答:唯一性检测时,发现插入项被加了锁(被前面delete了但是未purge),于是需要获取S锁(锁不止一个记录,因此是范围S锁而不是not gap);
-
既然是持有S锁,那么可不可能是有重复请求,导致duplicate key(1062)错误,因此持有S锁且不升级X锁?
答:最开始有猜想是此情况,但是通过查看数据库发现,相应cid对应的
e
记录并未出现重复记录,故排除此原因(因为e
表无唯一性限制,如果重复,则会出现相同记录);
最不常见情况(较极端)
最不常见情况下,其时SQL分别为:
事务1:insert into E
(cid, key, other) values (214, ‘xxx’, 'xxx) , (214, ‘yyy’, ‘yyy’) , (214, ‘zzz’, ‘zzz’) , ***;
事务2:delete from E
WHERE ( cid = 230 );
需要注意的点:
因为cid
既是非唯一索引,又是唯一索引的前缀组成部分,因此insert时候,除了要走唯一键流程,还要走非唯一键流程,这里就是唯一键流程OK,非唯一键流程卡主了;
解决方案设计
限制考虑
解决时考虑限制:
-
需要考虑到是多表的操作,一般情况下原子性不能丢失(所有成功才算成功);
-
保证原流程不受影响(出问题切回去,甚至设置批次id级别的灰度试试);
-
尽量别引入其他中间件(复杂了反而不好);
方案1
修改RR模式为RC模式,消除间隙锁,以大大减低死锁发生概率;
方案2
本来方案2有三种,这里直接给出其中我个人认为最友好的一种;
主要思路:
- 拆分大事务为小事务,并分情况选取流程;
- 是否强制原流程应有其他线上配置管理;
- 加一个简单记录表作为补偿日志表;【表结构可为(id, cid, param_str, addTime, status)】(Gson转换入参bean);
- 对其他欲接入的有定时修改批次需求的上游一个新接口,里面包含参数(Option,option内暂有一个字段即是否走新流程);【加新接口或原接口内改动】;
改造后流程图:
几个问题
-
这样设计会不会还有死锁?该怎么避免?
答:超极端情况下会有,例如情况3;删除IX_CoupongroupId即可,其实也是可以删除的,毕竟已经有了唯一索引了。。。
-
为什么
c
等其他表不需要拆出来进行事务?答:不需要,看其表结构,
cid
为主键一部分,删插不会引发死锁;所以这一块还是尽量保证原子性,以求真正处于中间状态的券批次的中间数据少; -
拆分出来的三个事务的执行先后顺序怎么确定?
答:可以更改,图中这样的顺序主要是为了出现极端问题快速失败; -
涉及多表的操作,如果出现中间数据状态怎么办(改了一半)?
答:新流程,只有在
e
表或a
表出现失败现象后出现数据中间状态(非原数据状态、非更新后数据应有状态),概率较低,且考虑到1. 凌晨n点操作; 2. 能通过补偿快速恢复; 因此新流程中信息的完整性与正确性有一定保障;
老流程则无影响,大事务,不存在中间状态; -
什么时候走新流程?
答:配置管理设置为“新流程可用” && (来自上游定时任务 && 非补偿请求 || 指定为新流程);
-
如何判断是补偿请求?
答:来自特定上游的请求,且补偿记录表中有相应cid的记录且status为待补偿;
方案3
鸵鸟法,看起来线上无影响,不如得过且过好吧。
方案对比及思考
建议方案2 或者 3吧,如果上游能改一下代码,则还可以考虑省略加表。
在涉及到并发批量修改(有insert)事务时,最好要少用唯一索引!!!
写在最后
一些参考链接:
如果有误,请一定指出!
开头结果揭晓好吧